EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
meshcentral-api.js
Go to the documentation of this file.
1/**
2 * MeshCentral API Client
3 *
4 * Provides programmatic access to MeshCentral server for:
5 * - Authentication and session management
6 * - Device listing and status
7 * - Login token generation for web-based remote desktop
8 * - Real-time device data synchronization
9 */
10
11const https = require('https');
12const crypto = require('crypto');
13const WebSocket = require('ws');
14
15/**
16 *
17 */
18class MeshCentralAPI {
19 /**
20 *
21 * @param config
22 */
23 constructor(config) {
24 // Support both object config and positional params
25 if (typeof config === 'object' && config !== null && config.url) {
26 this.baseUrl = config.url.replace(/\/$/, '');
27 this.username = config.username;
28 this.password = config.password;
29 } else if (typeof config === 'string') {
30 this.baseUrl = config.replace(/\/$/, '');
31 this.username = arguments[1];
32 this.password = arguments[2];
33 } else {
34 throw new Error('Invalid constructor arguments. Expected: { url, username, password } or (url, username, password)');
35 }
36 this.cookie = null;
37 this.ws = null;
38 this.messageHandlers = new Map();
39 this.nextMessageId = 1;
40 this.cachedMeshes = null;
41 this.cachedNodes = null;
42
43 // WebSocket reconnection state
44 this.reconnectAttempts = 0;
45 this.maxReconnectAttempts = 10;
46 this.reconnectDelay = 1000; // Start at 1 second
47 this.reconnecting = false;
48 this.intentionalClose = false;
49
50 // Connection monitoring
51 this.pingInterval = null;
52 this.lastPingTime = null;
53 this.lastPongTime = null;
54 }
55
56 /**
57 * Make HTTPS request to MeshCentral (bypassing self-signed cert)
58 * @param method
59 * @param path
60 * @param data
61 * @param headers
62 * @param isFormEncoded
63 */
64 async _request(method, path, data = null, headers = {}, isFormEncoded = false) {
65 return new Promise((resolve, reject) => {
66 const url = new URL(path, this.baseUrl);
67
68 // Prepare request body
69 let requestBody = null;
70 if (data) {
71 if (isFormEncoded) {
72 // URL-encode form data
73 requestBody = Object.keys(data)
74 .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
75 .join('&');
76 } else {
77 requestBody = JSON.stringify(data);
78 }
79 }
80
81 const options = {
82 hostname: url.hostname,
83 port: url.port || 443,
84 path: url.pathname + url.search,
85 method: method,
86 headers: {
87 'Content-Type': isFormEncoded ? 'application/x-www-form-urlencoded' : 'application/json',
88 ...headers
89 },
90 rejectUnauthorized: false // Accept self-signed certificates
91 };
92
93 if (requestBody) {
94 options.headers['Content-Length'] = Buffer.byteLength(requestBody);
95 }
96
97 // Add cookie if we have one
98 if (this.cookie) {
99 options.headers['Cookie'] = this.cookie;
100 }
101
102 const req = https.request(options, (res) => {
103 let body = '';
104
105 res.on('data', (chunk) => {
106 body += chunk;
107 });
108
109 res.on('end', () => {
110 // Save cookie from login - need both xid and xid.sig
111 if (res.headers['set-cookie']) {
112 // Join all cookies with semicolon
113 this.cookie = res.headers['set-cookie']
114 .map(c => c.split(';')[0])
115 .join('; ');
116 console.log(`đŸĒ Saved session cookie: ${this.cookie.substring(0, 100)}...`);
117 }
118
119 try {
120 const jsonBody = body ? JSON.parse(body) : {};
121 resolve({
122 statusCode: res.statusCode,
123 headers: res.headers,
124 body: jsonBody
125 });
126 } catch (e) {
127 resolve({
128 statusCode: res.statusCode,
129 headers: res.headers,
130 body: body
131 });
132 }
133 });
134 });
135
136 req.on('error', reject);
137
138 if (requestBody) {
139 req.write(requestBody);
140 }
141
142 req.end();
143 });
144 }
145
146 /**
147 * Login to MeshCentral and establish session
148 * Uses cookie-based authentication with form-encoded data
149 */
150 async login() {
151 try {
152 console.log(`🔐 MeshCentral login attempt - URL: ${this.baseUrl}, Username: ${this.username}`);
153
154 // MeshCentral uses form-based login that sets cookies
155 const response = await this._request('POST', '/login', {
156 action: 'login',
157 username: this.username,
158 password: this.password,
159 token: ''
160 }, {}, true); // true = form-encoded
161
162 // Check if we got a cookie (session established)
163 if (this.cookie) {
164 console.log('✅ MeshCentral login successful');
165 return true;
166 } else {
167 console.error('❌ MeshCentral login failed - no cookie received:', response.statusCode, response.body);
168 throw new Error(`Login failed: ${response.statusCode} - No session cookie received`);
169 }
170 } catch (error) {
171 console.error('❌ MeshCentral login error:', error.message);
172 throw error;
173 }
174 }
175
176 /**
177 * Connect to MeshCentral WebSocket for real-time data
178 */
179 async connectWebSocket() {
180 return new Promise((resolve, reject) => {
181 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
182 resolve(true);
183 return;
184 }
185
186 if (!this.cookie) {
187 reject(new Error('No session cookie available. Please login first.'));
188 return;
189 }
190
191 const wsUrl = this.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/control.ashx';
192
193 // console.log(`🔗 Connecting to WebSocket: ${wsUrl}`);
194 // console.log(`đŸĒ Cookie: ${this.cookie}`);
195
196 this.ws = new WebSocket(wsUrl, {
197 rejectUnauthorized: false,
198 headers: {
199 'Cookie': this.cookie
200 }
201 });
202
203 this.ws.on('open', () => {
204 console.log('✅ MeshCentral WebSocket connected');
205
206 // Reset reconnection state on successful connection
207 this.reconnectAttempts = 0;
208 this.reconnecting = false;
209
210 // Start ping/pong keep-alive
211 this.startKeepAlive();
212
213 // Authentication is handled via the Cookie header
214 // No need to send authcookie message
215
216 // Wait a bit for the connection to be fully ready
217 setTimeout(() => resolve(true), 500);
218 });
219
220 this.ws.on('message', (data) => {
221 try {
222 const message = JSON.parse(data.toString());
223
224 // Update pong time on any message (MeshCentral doesn't send explicit pongs)
225 this.lastPongTime = Date.now();
226
227 console.log(`📩 Received: ${message.action}`);
228 // console.log('📨 WebSocket received:', JSON.stringify(message).substring(0, 200));
229 this._handleWebSocketMessage(message);
230 } catch (e) {
231 console.error('Failed to parse WebSocket message:', e);
232 }
233 });
234
235 this.ws.on('error', (error) => {
236 console.error('❌ MeshCentral WebSocket error:', error.message);
237 // Don't reject here - let the close handler deal with reconnection
238 // reject(error);
239 });
240
241 this.ws.on('close', (code, reason) => {
242 console.log(`🔌 MeshCentral WebSocket disconnected - Code: ${code}, Reason: ${reason || 'unknown'}`);
243
244 // Stop keep-alive
245 this.stopKeepAlive();
246
247 this.ws = null;
248
249 // Auto-reconnect unless intentionally closed
250 if (!this.intentionalClose) {
251 this.handleReconnect();
252 } else {
253 console.log('âšī¸ Intentional close - not reconnecting');
254 }
255 });
256 });
257 }
258
259 /**
260 * Handle incoming WebSocket messages
261 * @param message
262 */
263 _handleWebSocketMessage(message) {
264 // Cache meshes and nodes when they arrive
265 if (message.action === 'meshes') {
266 this.cachedMeshes = message.meshes || [];
267 console.log(`Received ${this.cachedMeshes.length} mesh(es) from server`);
268 }
269 if (message.action === 'nodes') {
270 this.cachedNodes = message.nodes || [];
271 }
272 if (message.action === 'userinfo') {
273 console.log(`User info: siteadmin=${message.userinfo?.siteadmin}, email=${message.userinfo?.email}`);
274 }
275
276 // Handle responses to our requests
277 if (message.responseid && this.messageHandlers.has(message.responseid)) {
278 const handler = this.messageHandlers.get(message.responseid);
279 handler(message);
280 this.messageHandlers.delete(message.responseid);
281 }
282
283 // Handle events (device connect/disconnect, etc.)
284 if (message.action === 'event') {
285 this._handleEvent(message);
286 }
287 }
288
289 /**
290 * Handle MeshCentral events
291 * @param event
292 */
293 _handleEvent(event) {
294 // Events like: node connect, node disconnect, node change, etc.
295 console.log('MeshCentral event:', event);
296 }
297
298 /**
299 * Send WebSocket message and wait for response
300 * @param message
301 * @param timeout
302 */
303 async _sendWebSocketMessage(message, timeout = 5000) {
304 return new Promise((resolve, reject) => {
305 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
306 reject(new Error('WebSocket not connected'));
307 return;
308 }
309
310 const messageId = 'msg_' + this.nextMessageId++;
311 message.responseid = messageId;
312
313 const timer = setTimeout(() => {
314 this.messageHandlers.delete(messageId);
315 reject(new Error('WebSocket request timeout'));
316 }, timeout);
317
318 this.messageHandlers.set(messageId, (response) => {
319 clearTimeout(timer);
320 resolve(response);
321 });
322
323 this.ws.send(JSON.stringify(message));
324 });
325 }
326
327 /**
328 * Get list of all device groups (meshes) using control API
329 */
330 async getMeshes() {
331 try {
332 // Ensure WebSocket connection is established
333 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
334 this.cachedMeshes = null; // Reset cache
335 await this.connectWebSocket();
336 // Wait longer for MeshCentral to send us the meshes
337 await new Promise(resolve => setTimeout(resolve, 3000));
338 }
339
340 if (this.cachedMeshes) {
341 console.log(`Found ${this.cachedMeshes.length} mesh(es)`);
342 return this.cachedMeshes;
343 }
344
345 console.error('No meshes received from MeshCentral');
346 return [];
347 } catch (error) {
348 console.error('Failed to get meshes:', error.message);
349 return [];
350 }
351 }
352
353 /**
354 * Create a new device group (mesh) for tenant isolation
355 * @param meshName
356 * @param description
357 */
358 async createMesh(meshName, description = '') {
359 try {
360 // Ensure WebSocket connection is established
361 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
362 console.log('[MeshCentral] WebSocket not open, connecting...');
363 await this.connectWebSocket();
364 }
365
366 console.log(`[MeshCentral] Creating mesh: ${meshName}`);
367 const response = await this._sendWebSocketMessage({
368 action: 'createmesh',
369 meshname: meshName,
370 meshtype: 2, // Agent mesh (not AMT)
371 desc: description
372 });
373
374 console.log('[MeshCentral] Create mesh response:', JSON.stringify(response));
375
376 if (response && response.meshid) {
377 console.log(`✅ [MeshCentral] Created mesh: ${meshName} (${response.meshid})`);
378 return {
379 meshId: response.meshid,
380 meshName: meshName
381 };
382 }
383
384 if (response && response.error) {
385 console.error(`❌ [MeshCentral] Create mesh error: ${response.error}`);
386 } else {
387 console.error('❌ [MeshCentral] Failed to create mesh - no meshid in response:', response);
388 }
389 return null;
390 } catch (error) {
391 console.error('❌ [MeshCentral] Failed to create mesh (exception):', error.message);
392 return null;
393 }
394 }
395
396 /**
397 * Create a new user account for tenant isolation
398 * @param username
399 * @param password
400 * @param email
401 * @param realname
402 */
403 async createUser(username, password, email = '', realname = '') {
404 try {
405 // Ensure WebSocket connection is established
406 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
407 await this.connectWebSocket();
408 }
409
410 const response = await this._sendWebSocketMessage({
411 action: 'adduser',
412 username: username,
413 pass: password,
414 email: email,
415 emailVerified: true, // Skip email verification
416 randomPassword: false,
417 resetNextLogin: false,
418 siteadmin: 0, // No site admin rights
419 consoleOnly: false
420 });
421
422 if (response && response.result === 'ok') {
423 console.log(`Created user: ${username}`);
424 return {
425 username: username,
426 userId: response.userid || `user//${username}`
427 };
428 }
429
430 console.error('Failed to create user:', response);
431 return null;
432 } catch (error) {
433 console.error('Failed to create user:', error.message);
434 return null;
435 }
436 }
437
438 /**
439 * Add user to a mesh with specific permissions
440 * @param userId
441 * @param meshId
442 * @param rights
443 */
444 async addUserToMesh(userId, meshId, rights = 0xFFFFFFFF) {
445 try {
446 // Ensure WebSocket connection is established
447 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
448 await this.connectWebSocket();
449 }
450
451 // rights: 0xFFFFFFFF = full access
452 // Common rights:
453 // - Remote Desktop: 0x00000008
454 // - Terminal: 0x00000010
455 // - Files: 0x00000020
456 // - Full: 0xFFFFFFFF
457 const response = await this._sendWebSocketMessage({
458 action: 'addmeshuser',
459 meshid: meshId,
460 userid: userId,
461 meshadmin: rights
462 });
463
464 if (response && response.result === 'ok') {
465 console.log(`Added user ${userId} to mesh ${meshId}`);
466 return true;
467 }
468
469 console.error('Failed to add user to mesh:', response);
470 return false;
471 } catch (error) {
472 console.error('Failed to add user to mesh:', error.message);
473 return false;
474 }
475 }
476
477 /**
478 * Complete tenant setup: create user, create mesh, link them
479 * @param tenantData
480 */
481 async setupTenant(tenantData) {
482 try {
483 const { tenantId, tenantName, subdomain } = tenantData;
484
485 // Generate unique username and secure password
486 const username = subdomain || `tenant_${tenantId}`;
487 const password = crypto.randomBytes(32).toString('hex');
488 const meshName = `${tenantName} Devices`;
489
490 console.log(`Setting up MeshCentral for tenant: ${tenantName}`);
491
492 // Step 1: Create user
493 const user = await this.createUser(
494 username,
495 password,
496 `${username}@meshcentral.local`,
497 tenantName
498 );
499
500 if (!user) {
501 throw new Error('Failed to create MeshCentral user');
502 }
503
504 // Step 2: Create mesh (device group)
505 const mesh = await this.createMesh(
506 meshName,
507 `Device group for ${tenantName}`
508 );
509
510 if (!mesh) {
511 throw new Error('Failed to create MeshCentral mesh');
512 }
513
514 // Step 3: Add user to mesh with full permissions
515 const linked = await this.addUserToMesh(user.userId, mesh.meshId, 0xFFFFFFFF);
516
517 if (!linked) {
518 throw new Error('Failed to link user to mesh');
519 }
520
521 console.log(`✅ MeshCentral tenant setup complete for: ${tenantName}`);
522
523 return {
524 username: username,
525 password: password, // Return for encryption/storage
526 userId: user.userId,
527 meshId: mesh.meshId,
528 meshName: mesh.meshName
529 };
530
531 } catch (error) {
532 console.error('Failed to setup tenant:', error.message);
533 throw error;
534 }
535 }
536
537 /**
538 * Get list of all devices (nodes) using control API
539 */
540 async getNodes() {
541 try {
542 // Ensure WebSocket connection is established
543 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
544 await this.connectWebSocket();
545 }
546
547 const response = await this._sendWebSocketMessage({
548 action: 'nodes'
549 });
550
551 if (response && response.nodes) {
552 console.log(`Found ${response.nodes.length} node(s)`);
553 return response.nodes;
554 }
555
556 console.error('Failed to get nodes:', response);
557 return [];
558 } catch (error) {
559 console.error('Failed to get nodes:', error.message);
560 return [];
561 }
562 }
563
564 /**
565 * Get specific node details by node ID
566 * @param nodeId
567 */
568 async getNode(nodeId) {
569 try {
570 await this.connectWebSocket();
571
572 const response = await this._sendWebSocketMessage({
573 action: 'nodes',
574 nodeids: [nodeId]
575 });
576
577 const nodes = response.nodes || [];
578 return nodes.length > 0 ? nodes[0] : null;
579 } catch (error) {
580 console.error('Failed to get node:', error.message);
581 return null;
582 }
583 }
584
585 /**
586 * Get node system information
587 * @param nodeId
588 */
589 async getNodeSystemInfo(nodeId) {
590 try {
591 await this.connectWebSocket();
592
593 const response = await this._sendWebSocketMessage({
594 action: 'getsysinfo',
595 nodeid: nodeId
596 });
597
598 return response.sysinfo || null;
599 } catch (error) {
600 console.error('Failed to get node system info:', error.message);
601 return null;
602 }
603 }
604
605 /**
606 * Generate a login token for embedding MeshCentral
607 * Uses MeshCentral's WebSocket createLoginToken command to generate proper authentication tokens
608 * @param nodeId
609 */
610 async generateLoginToken(nodeId = null) {
611 try {
612 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
613 throw new Error('WebSocket not connected. Call connectWebSocket() first.');
614 }
615
616 // Request MeshCentral to generate a login token via WebSocket
617 const tokenName = `Dashboard-${Date.now()}`;
618 const response = await this._sendWebSocketMessage({
619 action: 'createLoginToken',
620 name: tokenName,
621 expire: 60 // 60 minutes
622 }, 10000);
623
624 if (!response.tokenUser || !response.tokenPass) {
625 throw new Error('Failed to get login token from MeshCentral: ' + (response.result || 'unknown error'));
626 }
627
628 // Build the embed URL with user/pass parameters
629 // MeshCentral supports ?user=xxx&pass=yyy for URL-based authentication
630 let embedUrl = `${this.baseUrl}/?user=${encodeURIComponent(response.tokenUser)}&pass=${encodeURIComponent(response.tokenPass)}`;
631
632 if (nodeId) {
633 embedUrl += `&gotonode=${encodeURIComponent(nodeId)}&viewmode=11&hide=31`;
634 // viewmode=11 is remote desktop
635 // hide=31 hides header(1), tabs(2), footer(4), title(8), toolbar(16) = 31
636 }
637
638 return {
639 tokenUser: response.tokenUser,
640 tokenPass: response.tokenPass,
641 embedUrl: embedUrl,
642 expiresAt: response.expire ? new Date(response.expire) : new Date(Date.now() + 3600000)
643 };
644 } catch (error) {
645 console.error('Failed to generate login token:', error.message);
646 throw error;
647 }
648 }
649
650 /**
651 * Get MeshAgent installer download link
652 * @param meshId
653 * @param platform
654 */
655 getAgentInstallerUrl(meshId, platform = 'win32') {
656 const platformMap = {
657 'win32': 'meshagent.exe',
658 'linux': 'meshagent',
659 'darwin': 'meshagent'
660 };
661
662 const filename = platformMap[platform] || 'meshagent.exe';
663 return `${this.baseUrl}/meshagents?id=${meshId}&installflags=0&meshinstall=${filename}`;
664 }
665
666 /**
667 * Get server information
668 */
669 async getServerInfo() {
670 try {
671 const response = await this._request('GET', '/');
672 return {
673 url: this.baseUrl,
674 connected: this.cookie !== null
675 };
676 } catch (error) {
677 console.error('Failed to get server info:', error.message);
678 return null;
679 }
680 }
681
682 /**
683 * Close WebSocket connection
684 */
685 disconnect() {
686 if (this.ws) {
687 this.ws.close();
688 this.ws = null;
689 }
690 }
691
692 /**
693 * Parse node data to extract useful information
694 * @param node
695 */
696 static parseNodeData(node) {
697 // Extract hardware identifiers for matching
698 const hardwareInfo = {
699 macAddresses: [],
700 motherboardId: null,
701 biosId: null,
702 systemUuid: null
703 };
704
705 // Extract MAC addresses from network interfaces
706 if (node.netif) {
707 for (const iface of Object.values(node.netif)) {
708 if (iface.mac && iface.mac !== '00:00:00:00:00:00') {
709 hardwareInfo.macAddresses.push(iface.mac.toUpperCase());
710 }
711 }
712 }
713
714 // Extract hardware IDs from system info (may be available in some fields)
715 if (node.sysinfo) {
716 hardwareInfo.motherboardId = node.sysinfo.motherboard?.uuid;
717 hardwareInfo.biosId = node.sysinfo.bios?.id;
718 hardwareInfo.systemUuid = node.sysinfo.system?.uuid;
719 }
720
721 return {
722 nodeId: node._id || node.nodeid,
723 name: node.name,
724 hostname: node.host,
725 meshId: node.meshid,
726 icon: node.icon,
727 conn: node.conn, // Connection status (bitmask)
728 connected: (node.conn & 1) === 1, // Bit 0 = agent connected
729 ip: node.ip,
730 publicIp: node.publicip,
731 os: node.osdesc,
732 platform: node.platform,
733 lastConnected: node.lastconnect ? new Date(node.lastconnect * 1000) : null,
734 lastSeen: node.lastseen ? new Date(node.lastseen * 1000) : null,
735 agentVersion: node.agentversion,
736 users: node.users,
737 hardware: hardwareInfo
738 };
739 }
740
741 /**
742 * Get detailed system information for a node
743 * Returns parsed hardware data suitable for agent table population
744 * @param sysinfo
745 */
746 static parseSystemInfo(sysinfo) {
747 if (!sysinfo) return null;
748
749 const parsed = {
750 os: {
751 name: sysinfo.osdesc || null,
752 version: sysinfo.osver || null,
753 architecture: sysinfo.arch || null
754 },
755 cpu: {
756 model: sysinfo.cpu?.name || null,
757 cores: sysinfo.cpu?.cores || null,
758 speed: sysinfo.cpu?.speed || null
759 },
760 memory: {
761 totalBytes: null,
762 modules: []
763 },
764 motherboard: {
765 manufacturer: sysinfo.board?.manufacturer || null,
766 product: sysinfo.board?.product || null,
767 uuid: sysinfo.board?.uuid || null
768 },
769 bios: {
770 vendor: sysinfo.bios?.vendor || null,
771 version: sysinfo.bios?.version || null,
772 date: sysinfo.bios?.date || null
773 },
774 network: [],
775 storage: [],
776 gpu: sysinfo.gpu || null
777 };
778
779 // Parse memory modules
780 if (sysinfo.memory) {
781 for (const [key, mem] of Object.entries(sysinfo.memory)) {
782 if (mem.capacity) {
783 const capacityMb = parseInt(mem.capacity);
784 parsed.memory.modules.push({
785 slot: key,
786 capacityMb: capacityMb,
787 manufacturer: mem.manufacturer || null,
788 partNumber: mem.partNumber || null,
789 speed: mem.speed || null
790 });
791 parsed.memory.totalBytes = (parsed.memory.totalBytes || 0) + (capacityMb * 1024 * 1024);
792 }
793 }
794 }
795
796 // Parse network interfaces
797 if (sysinfo.netif) {
798 for (const [name, iface] of Object.entries(sysinfo.netif)) {
799 parsed.network.push({
800 name: name,
801 mac: iface.mac || null,
802 ipv4: iface.v4addr || null,
803 ipv6: iface.v6addr || null,
804 gateway: iface.gw || null,
805 dhcp: iface.dhcp || false,
806 speed: iface.speed || null
807 });
808 }
809 }
810
811 // Parse storage
812 if (sysinfo.storage) {
813 for (const [name, drive] of Object.entries(sysinfo.storage)) {
814 parsed.storage.push({
815 name: name,
816 model: drive.model || null,
817 capacityMb: drive.capacity ? parseInt(drive.capacity) : null,
818 type: drive.type || null,
819 status: drive.status || null
820 });
821 }
822 }
823
824 return parsed;
825 }
826
827 /**
828 * Update device notes/tags (for storing tenant UUID and metadata)
829 * @param nodeId
830 * @param root0
831 * @param root0.tenantId
832 * @param root0.customerId
833 * @param root0.notes
834 * @param root0.tags
835 */
836 async updateDeviceMetadata(nodeId, { tenantId = null, customerId = null, notes = null, tags = null }) {
837 try {
838 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
839 await this.connectWebSocket();
840 }
841
842 const updates = {};
843
844 // Build tags array: [tenant:UUID, customer:ID, ...custom tags]
845 if (tenantId || customerId || tags) {
846 updates.tags = [];
847
848 if (tenantId) {
849 updates.tags.push(`tenant:${tenantId}`);
850 }
851
852 if (customerId) {
853 updates.tags.push(`customer:${customerId}`);
854 }
855
856 if (Array.isArray(tags)) {
857 updates.tags.push(...tags);
858 }
859 }
860
861 // Add description/notes
862 if (notes) {
863 updates.desc = notes;
864 }
865
866 const response = await this._sendWebSocketMessage({
867 action: 'changedevice',
868 nodeid: nodeId,
869 ...updates
870 });
871
872 console.log(`✅ Updated device metadata for ${nodeId.substring(0, 30)}...`);
873 return response;
874 } catch (error) {
875 console.error('Failed to update device metadata:', error.message);
876 return null;
877 }
878 }
879
880 /**
881 * Parse tags from device to extract tenant/customer IDs
882 * @param tags
883 */
884 static parseDeviceTags(tags) {
885 const result = {
886 tenantId: null,
887 customerId: null,
888 customTags: []
889 };
890
891 if (!Array.isArray(tags)) return result;
892
893 for (const tag of tags) {
894 if (typeof tag !== 'string') continue;
895
896 if (tag.startsWith('tenant:')) {
897 result.tenantId = tag.substring(7);
898 } else if (tag.startsWith('customer:')) {
899 result.customerId = tag.substring(9);
900 } else {
901 result.customTags.push(tag);
902 }
903 }
904
905 return result;
906 }
907
908 /**
909 * Handle WebSocket reconnection with exponential backoff
910 */
911 handleReconnect() {
912 if (this.reconnecting) {
913 return; // Already reconnecting
914 }
915
916 if (this.reconnectAttempts >= this.maxReconnectAttempts) {
917 console.error(`❌ Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`);
918 return;
919 }
920
921 this.reconnecting = true;
922
923 // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
924 const delay = Math.min(
925 this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
926 30000 // Max 30 seconds
927 );
928
929 this.reconnectAttempts++;
930 console.log(`âŗ Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
931
932 setTimeout(async () => {
933 try {
934 await this.connectWebSocket();
935 console.log('✅ Reconnected successfully');
936 } catch (err) {
937 console.error('❌ Reconnection failed:', err.message);
938 this.reconnecting = false;
939 // handleReconnect will be called again by the close handler
940 }
941 }, delay);
942 }
943
944 /**
945 * Start ping/pong keep-alive mechanism
946 * Sends a ping every 30 seconds to keep connection alive
947 */
948 startKeepAlive() {
949 // Clear any existing interval
950 this.stopKeepAlive();
951
952 this.pingInterval = setInterval(() => {
953 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
954 this.lastPingTime = Date.now();
955
956 // MeshCentral doesn't have a dedicated ping/pong
957 // Send a minimal request to keep connection alive
958 try {
959 this.ws.send(JSON.stringify({ action: 'ping' }));
960
961 // Check if we haven't received any messages in 60 seconds
962 if (this.lastPongTime && (Date.now() - this.lastPongTime) > 60000) {
963 console.warn('âš ī¸ No response from MeshCentral in 60s, connection may be dead');
964 // Force reconnect
965 this.ws.close();
966 }
967 } catch (error) {
968 console.error('❌ Failed to send keep-alive ping:', error.message);
969 this.ws.close();
970 }
971 }
972 }, 30000); // Every 30 seconds
973
974 console.log('💓 Keep-alive started (30s interval)');
975 }
976
977 /**
978 * Stop ping/pong keep-alive
979 */
980 stopKeepAlive() {
981 if (this.pingInterval) {
982 clearInterval(this.pingInterval);
983 this.pingInterval = null;
984 console.log('💔 Keep-alive stopped');
985 }
986 }
987
988 /**
989 * Gracefully close WebSocket connection (no auto-reconnect)
990 */
991 closeWebSocket() {
992 this.intentionalClose = true;
993 this.stopKeepAlive();
994
995 if (this.ws) {
996 this.ws.close();
997 this.ws = null;
998 }
999
1000 console.log('🛑 WebSocket closed gracefully');
1001 }
1002
1003 /**
1004 * Check WebSocket connection health
1005 */
1006 isWebSocketConnected() {
1007 return this.ws && this.ws.readyState === WebSocket.OPEN;
1008 }
1009
1010 /**
1011 * Get connection statistics
1012 */
1013 getConnectionStats() {
1014 return {
1015 connected: this.isWebSocketConnected(),
1016 reconnectAttempts: this.reconnectAttempts,
1017 lastPingTime: this.lastPingTime,
1018 lastPongTime: this.lastPongTime,
1019 latency: this.lastPongTime && this.lastPingTime
1020 ? this.lastPongTime - this.lastPingTime
1021 : null
1022 };
1023 }
1024}
1025
1026module.exports = MeshCentralAPI;