2 * MeshCentral API Client
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
11const https = require('https');
12const crypto = require('crypto');
13const WebSocket = require('ws');
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];
34 throw new Error('Invalid constructor arguments. Expected: { url, username, password } or (url, username, password)');
38 this.messageHandlers = new Map();
39 this.nextMessageId = 1;
40 this.cachedMeshes = null;
41 this.cachedNodes = null;
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;
50 // Connection monitoring
51 this.pingInterval = null;
52 this.lastPingTime = null;
53 this.lastPongTime = null;
57 * Make HTTPS request to MeshCentral (bypassing self-signed cert)
62 * @param isFormEncoded
64 async _request(method, path, data = null, headers = {}, isFormEncoded = false) {
65 return new Promise((resolve, reject) => {
66 const url = new URL(path, this.baseUrl);
68 // Prepare request body
69 let requestBody = null;
72 // URL-encode form data
73 requestBody = Object.keys(data)
74 .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
77 requestBody = JSON.stringify(data);
82 hostname: url.hostname,
83 port: url.port || 443,
84 path: url.pathname + url.search,
87 'Content-Type': isFormEncoded ? 'application/x-www-form-urlencoded' : 'application/json',
90 rejectUnauthorized: false // Accept self-signed certificates
94 options.headers['Content-Length'] = Buffer.byteLength(requestBody);
97 // Add cookie if we have one
99 options.headers['Cookie'] = this.cookie;
102 const req = https.request(options, (res) => {
105 res.on('data', (chunk) => {
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])
116 console.log(`đĒ Saved session cookie: ${this.cookie.substring(0, 100)}...`);
120 const jsonBody = body ? JSON.parse(body) : {};
122 statusCode: res.statusCode,
123 headers: res.headers,
128 statusCode: res.statusCode,
129 headers: res.headers,
136 req.on('error', reject);
139 req.write(requestBody);
147 * Login to MeshCentral and establish session
148 * Uses cookie-based authentication with form-encoded data
152 console.log(`đ MeshCentral login attempt - URL: ${this.baseUrl}, Username: ${this.username}`);
154 // MeshCentral uses form-based login that sets cookies
155 const response = await this._request('POST', '/login', {
157 username: this.username,
158 password: this.password,
160 }, {}, true); // true = form-encoded
162 // Check if we got a cookie (session established)
164 console.log('â
MeshCentral login successful');
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`);
171 console.error('â MeshCentral login error:', error.message);
177 * Connect to MeshCentral WebSocket for real-time data
179 async connectWebSocket() {
180 return new Promise((resolve, reject) => {
181 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
187 reject(new Error('No session cookie available. Please login first.'));
191 const wsUrl = this.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/control.ashx';
193 // console.log(`đ Connecting to WebSocket: ${wsUrl}`);
194 // console.log(`đĒ Cookie: ${this.cookie}`);
196 this.ws = new WebSocket(wsUrl, {
197 rejectUnauthorized: false,
199 'Cookie': this.cookie
203 this.ws.on('open', () => {
204 console.log('â
MeshCentral WebSocket connected');
206 // Reset reconnection state on successful connection
207 this.reconnectAttempts = 0;
208 this.reconnecting = false;
210 // Start ping/pong keep-alive
211 this.startKeepAlive();
213 // Authentication is handled via the Cookie header
214 // No need to send authcookie message
216 // Wait a bit for the connection to be fully ready
217 setTimeout(() => resolve(true), 500);
220 this.ws.on('message', (data) => {
222 const message = JSON.parse(data.toString());
224 // Update pong time on any message (MeshCentral doesn't send explicit pongs)
225 this.lastPongTime = Date.now();
227 console.log(`đŠ Received: ${message.action}`);
228 // console.log('đ¨ WebSocket received:', JSON.stringify(message).substring(0, 200));
229 this._handleWebSocketMessage(message);
231 console.error('Failed to parse WebSocket message:', e);
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
241 this.ws.on('close', (code, reason) => {
242 console.log(`đ MeshCentral WebSocket disconnected - Code: ${code}, Reason: ${reason || 'unknown'}`);
245 this.stopKeepAlive();
249 // Auto-reconnect unless intentionally closed
250 if (!this.intentionalClose) {
251 this.handleReconnect();
253 console.log('âšī¸ Intentional close - not reconnecting');
260 * Handle incoming WebSocket messages
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`);
269 if (message.action === 'nodes') {
270 this.cachedNodes = message.nodes || [];
272 if (message.action === 'userinfo') {
273 console.log(`User info: siteadmin=${message.userinfo?.siteadmin}, email=${message.userinfo?.email}`);
276 // Handle responses to our requests
277 if (message.responseid && this.messageHandlers.has(message.responseid)) {
278 const handler = this.messageHandlers.get(message.responseid);
280 this.messageHandlers.delete(message.responseid);
283 // Handle events (device connect/disconnect, etc.)
284 if (message.action === 'event') {
285 this._handleEvent(message);
290 * Handle MeshCentral events
293 _handleEvent(event) {
294 // Events like: node connect, node disconnect, node change, etc.
295 console.log('MeshCentral event:', event);
299 * Send WebSocket message and wait for response
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'));
310 const messageId = 'msg_' + this.nextMessageId++;
311 message.responseid = messageId;
313 const timer = setTimeout(() => {
314 this.messageHandlers.delete(messageId);
315 reject(new Error('WebSocket request timeout'));
318 this.messageHandlers.set(messageId, (response) => {
323 this.ws.send(JSON.stringify(message));
328 * Get list of all device groups (meshes) using control API
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));
340 if (this.cachedMeshes) {
341 console.log(`Found ${this.cachedMeshes.length} mesh(es)`);
342 return this.cachedMeshes;
345 console.error('No meshes received from MeshCentral');
348 console.error('Failed to get meshes:', error.message);
354 * Create a new device group (mesh) for tenant isolation
358 async createMesh(meshName, description = '') {
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();
366 console.log(`[MeshCentral] Creating mesh: ${meshName}`);
367 const response = await this._sendWebSocketMessage({
368 action: 'createmesh',
370 meshtype: 2, // Agent mesh (not AMT)
374 console.log('[MeshCentral] Create mesh response:', JSON.stringify(response));
376 if (response && response.meshid) {
377 console.log(`â
[MeshCentral] Created mesh: ${meshName} (${response.meshid})`);
379 meshId: response.meshid,
384 if (response && response.error) {
385 console.error(`â [MeshCentral] Create mesh error: ${response.error}`);
387 console.error('â [MeshCentral] Failed to create mesh - no meshid in response:', response);
391 console.error('â [MeshCentral] Failed to create mesh (exception):', error.message);
397 * Create a new user account for tenant isolation
403 async createUser(username, password, email = '', realname = '') {
405 // Ensure WebSocket connection is established
406 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
407 await this.connectWebSocket();
410 const response = await this._sendWebSocketMessage({
415 emailVerified: true, // Skip email verification
416 randomPassword: false,
417 resetNextLogin: false,
418 siteadmin: 0, // No site admin rights
422 if (response && response.result === 'ok') {
423 console.log(`Created user: ${username}`);
426 userId: response.userid || `user//${username}`
430 console.error('Failed to create user:', response);
433 console.error('Failed to create user:', error.message);
439 * Add user to a mesh with specific permissions
444 async addUserToMesh(userId, meshId, rights = 0xFFFFFFFF) {
446 // Ensure WebSocket connection is established
447 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
448 await this.connectWebSocket();
451 // rights: 0xFFFFFFFF = full access
453 // - Remote Desktop: 0x00000008
454 // - Terminal: 0x00000010
455 // - Files: 0x00000020
456 // - Full: 0xFFFFFFFF
457 const response = await this._sendWebSocketMessage({
458 action: 'addmeshuser',
464 if (response && response.result === 'ok') {
465 console.log(`Added user ${userId} to mesh ${meshId}`);
469 console.error('Failed to add user to mesh:', response);
472 console.error('Failed to add user to mesh:', error.message);
478 * Complete tenant setup: create user, create mesh, link them
481 async setupTenant(tenantData) {
483 const { tenantId, tenantName, subdomain } = tenantData;
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`;
490 console.log(`Setting up MeshCentral for tenant: ${tenantName}`);
492 // Step 1: Create user
493 const user = await this.createUser(
496 `${username}@meshcentral.local`,
501 throw new Error('Failed to create MeshCentral user');
504 // Step 2: Create mesh (device group)
505 const mesh = await this.createMesh(
507 `Device group for ${tenantName}`
511 throw new Error('Failed to create MeshCentral mesh');
514 // Step 3: Add user to mesh with full permissions
515 const linked = await this.addUserToMesh(user.userId, mesh.meshId, 0xFFFFFFFF);
518 throw new Error('Failed to link user to mesh');
521 console.log(`â
MeshCentral tenant setup complete for: ${tenantName}`);
525 password: password, // Return for encryption/storage
528 meshName: mesh.meshName
532 console.error('Failed to setup tenant:', error.message);
538 * Get list of all devices (nodes) using control API
542 // Ensure WebSocket connection is established
543 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
544 await this.connectWebSocket();
547 const response = await this._sendWebSocketMessage({
551 if (response && response.nodes) {
552 console.log(`Found ${response.nodes.length} node(s)`);
553 return response.nodes;
556 console.error('Failed to get nodes:', response);
559 console.error('Failed to get nodes:', error.message);
565 * Get specific node details by node ID
568 async getNode(nodeId) {
570 await this.connectWebSocket();
572 const response = await this._sendWebSocketMessage({
577 const nodes = response.nodes || [];
578 return nodes.length > 0 ? nodes[0] : null;
580 console.error('Failed to get node:', error.message);
586 * Get node system information
589 async getNodeSystemInfo(nodeId) {
591 await this.connectWebSocket();
593 const response = await this._sendWebSocketMessage({
594 action: 'getsysinfo',
598 return response.sysinfo || null;
600 console.error('Failed to get node system info:', error.message);
606 * Generate a login token for embedding MeshCentral
607 * Uses MeshCentral's WebSocket createLoginToken command to generate proper authentication tokens
610 async generateLoginToken(nodeId = null) {
612 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
613 throw new Error('WebSocket not connected. Call connectWebSocket() first.');
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',
621 expire: 60 // 60 minutes
624 if (!response.tokenUser || !response.tokenPass) {
625 throw new Error('Failed to get login token from MeshCentral: ' + (response.result || 'unknown error'));
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)}`;
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
639 tokenUser: response.tokenUser,
640 tokenPass: response.tokenPass,
642 expiresAt: response.expire ? new Date(response.expire) : new Date(Date.now() + 3600000)
645 console.error('Failed to generate login token:', error.message);
651 * Get MeshAgent installer download link
655 getAgentInstallerUrl(meshId, platform = 'win32') {
656 const platformMap = {
657 'win32': 'meshagent.exe',
658 'linux': 'meshagent',
659 'darwin': 'meshagent'
662 const filename = platformMap[platform] || 'meshagent.exe';
663 return `${this.baseUrl}/meshagents?id=${meshId}&installflags=0&meshinstall=${filename}`;
667 * Get server information
669 async getServerInfo() {
671 const response = await this._request('GET', '/');
674 connected: this.cookie !== null
677 console.error('Failed to get server info:', error.message);
683 * Close WebSocket connection
693 * Parse node data to extract useful information
696 static parseNodeData(node) {
697 // Extract hardware identifiers for matching
698 const hardwareInfo = {
705 // Extract MAC addresses from network interfaces
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());
714 // Extract hardware IDs from system info (may be available in some fields)
716 hardwareInfo.motherboardId = node.sysinfo.motherboard?.uuid;
717 hardwareInfo.biosId = node.sysinfo.bios?.id;
718 hardwareInfo.systemUuid = node.sysinfo.system?.uuid;
722 nodeId: node._id || node.nodeid,
727 conn: node.conn, // Connection status (bitmask)
728 connected: (node.conn & 1) === 1, // Bit 0 = agent connected
730 publicIp: node.publicip,
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,
737 hardware: hardwareInfo
742 * Get detailed system information for a node
743 * Returns parsed hardware data suitable for agent table population
746 static parseSystemInfo(sysinfo) {
747 if (!sysinfo) return null;
751 name: sysinfo.osdesc || null,
752 version: sysinfo.osver || null,
753 architecture: sysinfo.arch || null
756 model: sysinfo.cpu?.name || null,
757 cores: sysinfo.cpu?.cores || null,
758 speed: sysinfo.cpu?.speed || null
765 manufacturer: sysinfo.board?.manufacturer || null,
766 product: sysinfo.board?.product || null,
767 uuid: sysinfo.board?.uuid || null
770 vendor: sysinfo.bios?.vendor || null,
771 version: sysinfo.bios?.version || null,
772 date: sysinfo.bios?.date || null
776 gpu: sysinfo.gpu || null
779 // Parse memory modules
780 if (sysinfo.memory) {
781 for (const [key, mem] of Object.entries(sysinfo.memory)) {
783 const capacityMb = parseInt(mem.capacity);
784 parsed.memory.modules.push({
786 capacityMb: capacityMb,
787 manufacturer: mem.manufacturer || null,
788 partNumber: mem.partNumber || null,
789 speed: mem.speed || null
791 parsed.memory.totalBytes = (parsed.memory.totalBytes || 0) + (capacityMb * 1024 * 1024);
796 // Parse network interfaces
798 for (const [name, iface] of Object.entries(sysinfo.netif)) {
799 parsed.network.push({
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
812 if (sysinfo.storage) {
813 for (const [name, drive] of Object.entries(sysinfo.storage)) {
814 parsed.storage.push({
816 model: drive.model || null,
817 capacityMb: drive.capacity ? parseInt(drive.capacity) : null,
818 type: drive.type || null,
819 status: drive.status || null
828 * Update device notes/tags (for storing tenant UUID and metadata)
831 * @param root0.tenantId
832 * @param root0.customerId
836 async updateDeviceMetadata(nodeId, { tenantId = null, customerId = null, notes = null, tags = null }) {
838 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
839 await this.connectWebSocket();
844 // Build tags array: [tenant:UUID, customer:ID, ...custom tags]
845 if (tenantId || customerId || tags) {
849 updates.tags.push(`tenant:${tenantId}`);
853 updates.tags.push(`customer:${customerId}`);
856 if (Array.isArray(tags)) {
857 updates.tags.push(...tags);
861 // Add description/notes
863 updates.desc = notes;
866 const response = await this._sendWebSocketMessage({
867 action: 'changedevice',
872 console.log(`â
Updated device metadata for ${nodeId.substring(0, 30)}...`);
875 console.error('Failed to update device metadata:', error.message);
881 * Parse tags from device to extract tenant/customer IDs
884 static parseDeviceTags(tags) {
891 if (!Array.isArray(tags)) return result;
893 for (const tag of tags) {
894 if (typeof tag !== 'string') continue;
896 if (tag.startsWith('tenant:')) {
897 result.tenantId = tag.substring(7);
898 } else if (tag.startsWith('customer:')) {
899 result.customerId = tag.substring(9);
901 result.customTags.push(tag);
909 * Handle WebSocket reconnection with exponential backoff
912 if (this.reconnecting) {
913 return; // Already reconnecting
916 if (this.reconnectAttempts >= this.maxReconnectAttempts) {
917 console.error(`â Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`);
921 this.reconnecting = true;
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
929 this.reconnectAttempts++;
930 console.log(`âŗ Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
932 setTimeout(async () => {
934 await this.connectWebSocket();
935 console.log('â
Reconnected successfully');
937 console.error('â Reconnection failed:', err.message);
938 this.reconnecting = false;
939 // handleReconnect will be called again by the close handler
945 * Start ping/pong keep-alive mechanism
946 * Sends a ping every 30 seconds to keep connection alive
949 // Clear any existing interval
950 this.stopKeepAlive();
952 this.pingInterval = setInterval(() => {
953 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
954 this.lastPingTime = Date.now();
956 // MeshCentral doesn't have a dedicated ping/pong
957 // Send a minimal request to keep connection alive
959 this.ws.send(JSON.stringify({ action: 'ping' }));
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');
968 console.error('â Failed to send keep-alive ping:', error.message);
972 }, 30000); // Every 30 seconds
974 console.log('đ Keep-alive started (30s interval)');
978 * Stop ping/pong keep-alive
981 if (this.pingInterval) {
982 clearInterval(this.pingInterval);
983 this.pingInterval = null;
984 console.log('đ Keep-alive stopped');
989 * Gracefully close WebSocket connection (no auto-reconnect)
992 this.intentionalClose = true;
993 this.stopKeepAlive();
1000 console.log('đ WebSocket closed gracefully');
1004 * Check WebSocket connection health
1006 isWebSocketConnected() {
1007 return this.ws && this.ws.readyState === WebSocket.OPEN;
1011 * Get connection statistics
1013 getConnectionStats() {
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
1026module.exports = MeshCentralAPI;