2 * @file Tenant MeshCentral Device Group Auto-Sync Service
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.
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
17 * Synchronization logic:
18 * 1. Query all tenants from database
19 * 2. Fetch all existing device groups (meshes) from MeshCentral
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`
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"
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
40 * - All endpoints require authentication (`authenticateToken` middleware)
41 * - Root tenant credentials required for MeshCentral API access
42 * - Environment variables store admin credentials
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
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}
57 * Tenant MeshCentral Auto-Sync
58 * Ensures all tenants have device groups in MeshCentral
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
66const express = require('express');
67const router = express.Router();
68const db = require('../db');
69const MeshCentralAPI = require('../lib/meshcentral-api');
70const authenticateToken = require('../middleware/auth');
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';
77 * Creates and authenticates MeshCentral API client instance.
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.).
84 * @function getMeshAPI
85 * @returns {Promise<MeshCentralAPI>} Authenticated MeshCentral API client instance
86 * @throws {Error} If MeshCentral server unreachable or authentication fails
88 * const api = await getMeshAPI();
89 * const meshes = await api.getMeshes();
93 * Get or create MeshCentral API instance
95async function getMeshAPI() {
96 const api = new MeshCentralAPI({
98 username: MESHCENTRAL_USER,
99 password: MESHCENTRAL_PASSWORD
107 * Ensures tenant has associated device group (mesh) in MeshCentral.
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`
116 * Name matching precedence:
117 * - **Exact match**: "{tenant.name} Devices"
118 * - **Exact fallback**: "{tenant.name}"
119 * - **Partial match**: Contains tenant.name AND "Devices"
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
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 }
142 * Ensure tenant has a device group in MeshCentral
143 * Matches existing groups by ID or name, creates new ones if needed
146 * @param existingMeshes
148async function ensureTenantMesh(api, tenant, existingMeshes = null) {
150 const expectedMeshName = `${tenant.name} Devices`;
152 // Fetch meshes if not provided
153 if (!existingMeshes) {
154 existingMeshes = await api.getMeshes();
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);
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 };
166 console.log(`ā ļø [TenantSync] Tenant ${tenant.tenant_id} mesh not found, searching for match...`);
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);
174 matchingMesh = existingMeshes.find(m => (m.name || '') === tenant.name);
176 // Only fall back to partial match if no exact matches found
178 matchingMesh = existingMeshes.find(m => (m.name || '').includes(tenant.name) && (m.name || '').includes('Devices'));
182 console.log(`š [TenantSync] Found existing mesh "${matchingMesh.name}" - linking to tenant ${tenant.tenant_id}`);
184 // Link this mesh to the tenant
186 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
187 [matchingMesh._id, tenant.tenant_id]
190 console.log(`ā
[TenantSync] Linked mesh ${matchingMesh._id} to tenant ${tenant.tenant_id}`);
191 return { meshId: matchingMesh._id, created: false, linked: true };
195 console.log(`š¦ [TenantSync] Creating mesh for tenant ${tenant.tenant_id}: ${expectedMeshName}`);
197 const result = await api.createMesh(expectedMeshName, `Device group for ${tenant.name} (tenant:${tenant.tenant_id})`);
199 if (!result || !result.meshId) {
200 throw new Error('Failed to create mesh in MeshCentral');
203 // Store mesh ID in database
205 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
206 [result.meshId, tenant.tenant_id]
209 console.log(`ā
[TenantSync] Created and linked mesh ${result.meshId} for tenant ${tenant.tenant_id}`);
211 return { meshId: result.meshId, created: true, linked: false };
214 console.error(`ā [TenantSync] Error ensuring mesh for tenant ${tenant.tenant_id}:`, error.message);
220 * @api {post} /tenant-meshcentral-sync/sync-all Sync All Tenants with MeshCentral
221 * @apiName SyncAllTenantsMeshCentral
222 * @apiGroup TenantSync
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.
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
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:
254 * "totalTenants": 10,
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}
266 * Sync all tenants with MeshCentral
267 * POST /api/tenant-meshcentral-sync/sync-all
269router.post('/sync-all', authenticateToken, async (req, res) => {
271 console.log('\nš [TenantSync] Starting full tenant-mesh sync...');
274 const tenantsResult = await db.query(
275 'SELECT tenant_id, name, meshcentral_group_id, meshcentral_setup_complete FROM tenants ORDER BY created_at'
278 if (tenantsResult.rows.length === 0) {
281 message: 'No tenants to sync',
286 console.log(`š [TenantSync] Found ${tenantsResult.rows.length} tenants`);
288 // Connect to MeshCentral
289 const api = await getMeshAPI();
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)`);
296 // Process each tenant
298 for (const tenant of tenantsResult.rows) {
300 const result = await ensureTenantMesh(api, tenant, existingMeshes);
302 tenantId: tenant.tenant_id,
303 tenantName: tenant.name,
304 meshId: result.meshId,
305 created: result.created,
306 linked: result.linked,
311 tenantId: tenant.tenant_id,
312 tenantName: tenant.name,
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;
324 console.log(`\nā
[TenantSync] Sync complete: ${created} created, ${linked} linked, ${existing} existing, ${failed} failed`);
328 message: `Synced ${tenantsResult.rows.length} tenants`,
337 console.error('ā [TenantSync] Error during sync:', error);
338 res.status(500).json({
340 error: 'Failed to sync tenants with MeshCentral',
341 details: error.message
347 * @api {post} /tenant-meshcentral-sync/ensure/:tenantId Ensure Tenant Device Group Exists
348 * @apiName EnsureTenantMeshCentral
349 * @apiGroup TenantSync
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
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:
371 * "meshId": "mesh/domain/abc123",
374 * "message": "Created new mesh for tenant 5"
379 * Ensure single tenant has a mesh
380 * POST /api/tenant-meshcentral-sync/ensure/:tenantId
382router.post('/ensure/:tenantId', authenticateToken, async (req, res) => {
384 const { tenantId } = req.params;
386 console.log(`\nš [TenantSync] Ensuring mesh for tenant ${tenantId}...`);
389 const tenantResult = await db.query(
390 'SELECT tenant_id, name, meshcentral_group_id, meshcentral_setup_complete FROM tenants WHERE tenant_id = $1',
394 if (tenantResult.rows.length === 0) {
395 return res.status(404).json({
397 error: 'Tenant not found'
401 const tenant = tenantResult.rows[0];
403 // Connect to MeshCentral and ensure mesh exists
404 const api = await getMeshAPI();
405 const result = await ensureTenantMesh(api, tenant);
409 tenantId: tenant.tenant_id,
410 tenantName: tenant.name,
411 meshId: result.meshId,
412 created: result.created
416 console.error('ā [TenantSync] Error ensuring mesh:', error);
417 res.status(500).json({
419 error: 'Failed to ensure mesh for tenant',
420 details: error.message
426 * @api {get} /tenant-meshcentral-sync/status Get Tenant Sync Status
427 * @apiName GetTenantSyncStatus
428 * @apiGroup TenantSync
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
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:
450 * "totalTenants": 15,
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}
461 * Get sync status for all tenants
462 * GET /api/tenant-meshcentral-sync/status
464router.get('/status', authenticateToken, async (req, res) => {
466 const tenantsResult = await db.query(`
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
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));
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)
494 const needsSync = status.filter(s => s.needsSync).length;
498 totalTenants: status.length,
504 console.error('ā [TenantSync] Error getting status:', error);
505 res.status(500).json({
507 error: 'Failed to get sync status',
508 details: error.message
513module.exports = router;