EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
webhooks.js
Go to the documentation of this file.
1/**
2 * @file webhooks.js
3 * @module WebhooksRoutes
4 * @description MeshCentral webhook handler for device events and agent auto-linking. Receives webhooks from MeshCentral for device lifecycle events (connect, disconnect, change) and automatically links devices to agents by hostname or MAC address. Includes webhook verification, auto-creation of agents, and configuration endpoints.
5 * @see {@link ../lib/meshcentral-api} for MeshCentral API integration
6 * @see {@link ../db} for database connection
7 * @apiDefine WebhooksGroup Webhooks
8 * @apiGroup Webhooks
9 * @apiHeader {string} [X-MeshCentral-Secret] Webhook secret for authentication (optional in development).
10 * @apiError (Error 401) Unauthorized Invalid webhook secret.
11 * @apiError (Error 500) ServerError Failed to process webhook.
12 *
13 * Webhook Events:
14 * - device.connect/node.connect: Device comes online, triggers auto-link
15 * - device.disconnect/node.disconnect: Device goes offline, updates connection status
16 * - device.change/node.change: Device information changes, updates last seen
17 */
18
19const express = require('express');
20const router = express.Router();
21const db = require('../db');
22const MeshCentralAPI = require('../lib/meshcentral-api');
23
24/**
25 * Verify webhook is from MeshCentral
26 * @param req
27 * @param res
28 * @param next
29 */
30function verifyWebhookAuth(req, res, next) {
31 const webhookSecret = process.env.MESHCENTRAL_WEBHOOK_SECRET;
32
33 if (!webhookSecret) {
34 // If no secret configured, allow (for development)
35 console.warn('โš ๏ธ [MeshWebhook] No MESHCENTRAL_WEBHOOK_SECRET configured');
36 return next();
37 }
38
39 const providedSecret = req.headers['x-meshcentral-secret'] || req.query.secret;
40
41 if (providedSecret !== webhookSecret) {
42 console.error('โŒ [MeshWebhook] Invalid webhook secret');
43 return res.status(401).json({ error: 'Unauthorized' });
44 }
45
46 next();
47}
48
49/**
50 * Get MeshCentral API instance
51 */
52async function getMeshAPI() {
53 const api = new MeshCentralAPI({
54 url: process.env.MESHCENTRAL_URL || 'https://rmm-psa-meshcentral-aq48h.ondigitalocean.app',
55 username: process.env.MESHCENTRAL_ADMIN_USER || 'admin',
56 password: process.env.MESHCENTRAL_ADMIN_PASS || 'admin'
57 });
58
59 await api.login();
60 return api;
61}
62
63/**
64 * Try to auto-link a device to an agent
65 * @param nodeId
66 * @param nodeName
67 * @param nodeHostname
68 * @param nodeInfo
69 */
70async function autoLinkDevice(nodeId, nodeName, nodeHostname, nodeInfo = {}) {
71 try {
72 console.log(`\n๐Ÿ”— [MeshWebhook] Auto-linking device: ${nodeHostname || nodeName} (${nodeId})`);
73
74 // Check if already linked
75 const existingLink = await db.query(
76 'SELECT agent_id, hostname FROM agents WHERE meshcentral_nodeid = $1',
77 [nodeId]
78 );
79
80 if (existingLink.rows.length > 0) {
81 console.log(`โœ… [MeshWebhook] Device already linked to agent ${existingLink.rows[0].agent_id}`);
82 // Update connection status
83 await db.query(
84 'UPDATE agents SET meshcentral_connected = true, meshcentral_last_seen = NOW() WHERE meshcentral_nodeid = $1',
85 [nodeId]
86 );
87 return { alreadyLinked: true, agentId: existingLink.rows[0].agent_id };
88 }
89
90 const hostname = nodeHostname || nodeName;
91 if (!hostname) {
92 console.log(`โš ๏ธ [MeshWebhook] Device has no hostname, cannot auto-link`);
93 return { success: false, reason: 'no_hostname' };
94 }
95
96 // Try to find matching agent by hostname
97 const hostnameMatch = await db.query(
98 'SELECT agent_id, agent_uuid, hostname, tenant_id FROM agents WHERE LOWER(hostname) = LOWER($1) AND meshcentral_nodeid IS NULL LIMIT 1',
99 [hostname]
100 );
101
102 if (hostnameMatch.rows.length > 0) {
103 const agent = hostnameMatch.rows[0];
104
105 // Link agent to device
106 await db.query(
107 `UPDATE agents
108 SET meshcentral_nodeid = $1,
109 meshcentral_connected = true,
110 meshcentral_last_seen = NOW()
111 WHERE agent_id = $2`,
112 [nodeId, agent.agent_id]
113 );
114
115 console.log(`โœ… [MeshWebhook] Linked agent ${agent.agent_id} (${agent.hostname}) to device ${nodeId}`);
116
117 return {
118 success: true,
119 agentId: agent.agent_id,
120 agentUuid: agent.agent_uuid,
121 hostname: agent.hostname,
122 method: 'hostname'
123 };
124 }
125
126 // Try MAC address matching if available
127 if (nodeInfo.mac || (nodeInfo.netif && Object.keys(nodeInfo.netif).length > 0)) {
128 const macAddresses = [];
129
130 if (nodeInfo.mac) {
131 macAddresses.push(nodeInfo.mac);
132 }
133
134 if (nodeInfo.netif) {
135 for (const iface of Object.values(nodeInfo.netif)) {
136 if (iface.mac && iface.mac !== '00:00:00:00:00:00') {
137 macAddresses.push(iface.mac);
138 }
139 }
140 }
141
142 for (const mac of macAddresses) {
143 const macMatch = await db.query(`
144 SELECT agent_id, agent_uuid, hostname
145 FROM agents
146 WHERE meshcentral_nodeid IS NULL
147 AND hardware_data IS NOT NULL
148 AND hardware_data::text ILIKE $1
149 LIMIT 1
150 `, [`%${mac}%`]);
151
152 if (macMatch.rows.length > 0) {
153 const agent = macMatch.rows[0];
154
155 await db.query(
156 `UPDATE agents
157 SET meshcentral_nodeid = $1,
158 meshcentral_connected = true,
159 meshcentral_last_seen = NOW()
160 WHERE agent_id = $2`,
161 [nodeId, agent.agent_id]
162 );
163
164 console.log(`โœ… [MeshWebhook] Linked agent ${agent.agent_id} via MAC ${mac}`);
165
166 return {
167 success: true,
168 agentId: agent.agent_id,
169 agentUuid: agent.agent_uuid,
170 hostname: agent.hostname,
171 method: 'mac_address',
172 mac: mac
173 };
174 }
175 }
176 }
177
178 // No existing agent found - create a new one
179 console.log(`๐Ÿ†• [MeshWebhook] No matching agent found - creating new agent for: ${hostname}`);
180
181 const crypto = require('crypto');
182 const newAgentUuid = crypto.randomUUID();
183
184 // Determine platform from node info
185 let platform = 'unknown';
186 if (nodeInfo.osdesc || nodeInfo.platform) {
187 const osDesc = (nodeInfo.osdesc || nodeInfo.platform || '').toLowerCase();
188 if (osDesc.includes('windows')) platform = 'windows';
189 else if (osDesc.includes('linux')) platform = 'linux';
190 else if (osDesc.includes('darwin') || osDesc.includes('mac')) platform = 'darwin';
191 }
192
193 // Get default tenant (tenant_id = 1) or null
194 const tenantResult = await db.query('SELECT tenant_id FROM tenants WHERE tenant_id = 1 LIMIT 1');
195 const defaultTenantId = tenantResult.rows.length > 0 ? tenantResult.rows[0].tenant_id : null;
196
197 // Create new agent
198 const insertResult = await db.query(
199 `INSERT INTO agents (
200 agent_uuid, tenant_id, hostname, platform,
201 meshcentral_nodeid, meshcentral_connected, meshcentral_last_seen,
202 status, last_seen, created_at
203 ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), 'online', NOW(), NOW())
204 RETURNING agent_id, agent_uuid, hostname`,
205 [newAgentUuid, defaultTenantId, hostname, platform, nodeId, true]
206 );
207
208 const newAgent = insertResult.rows[0];
209 console.log(`โœ… [MeshWebhook] Created new agent ${newAgent.agent_id} (${newAgent.hostname}) linked to device ${nodeId}`);
210
211 return {
212 success: true,
213 created: true,
214 agentId: newAgent.agent_id,
215 agentUuid: newAgent.agent_uuid,
216 hostname: newAgent.hostname,
217 method: 'created_new'
218 };
219
220 } catch (error) {
221 console.error('โŒ [MeshWebhook] Auto-link error:', error.message);
222 throw error;
223 }
224}
225
226/**
227 * @api {post} /webhooks/meshcentral Receive MeshCentral event webhook
228 * @apiName ReceiveMeshCentralWebhook
229 * @apiGroup Webhooks
230 * @apiDescription Main webhook endpoint that receives device lifecycle events from MeshCentral. Handles device connect/disconnect/change events, auto-links devices to agents by hostname or MAC address, and creates new agents if no match is found. Requires webhook secret authentication (X-MeshCentral-Secret header or query param).
231 * @apiHeader {string} [X-MeshCentral-Secret] Webhook authentication secret (optional in dev).
232 * @apiParam {string} event Event type (device.connect, device.disconnect, device.change, node.connect, node.disconnect, node.change).
233 * @apiParam {string} nodeId MeshCentral node ID of the device.
234 * @apiParam {string} [nodeName] Device name.
235 * @apiParam {string} [hostname] Device hostname.
236 * @apiParam {object} [node] Device information object (platform, MAC, network interfaces, etc.).
237 * @apiSuccess {boolean} success Operation success status.
238 * @apiSuccess {string} event Event type processed.
239 * @apiSuccess {Object} [linkResult] Auto-link result (for connect events).
240 * @apiSuccess {boolean} [linkResult.success] Auto-link success status.
241 * @apiSuccess {string} [linkResult.agentId] Linked or created agent ID.
242 * @apiSuccess {string} [linkResult.method] Link method (hostname, mac_address, created_new).
243 * @apiSuccess {boolean} [updated] Device status updated (for disconnect/change events).
244 * @apiError (Error 401) Unauthorized Invalid webhook secret.
245 * @apiError (Error 500) ServerError Failed to process webhook.
246 * @apiExample {curl} Example usage:
247 * curl -X POST -H "X-MeshCentral-Secret: <secret>" -d '{"event":"device.connect","nodeId":"node//abc123","nodeName":"SERVER01","hostname":"server01.local"}' https://api.example.com/webhooks/meshcentral
248 */
249router.post('/meshcentral', verifyWebhookAuth, async (req, res) => {
250 try {
251 const event = req.body;
252
253 console.log(`\n๐Ÿ“ฌ [MeshWebhook] Received event: ${event.event}`);
254 console.log(` Node: ${event.nodeName || event.nodeId}`);
255
256 // Handle different event types
257 switch (event.event) {
258 case 'device.connect':
259 case 'node.connect': {
260 // Device connected - try to auto-link
261 const result = await autoLinkDevice(
262 event.nodeId,
263 event.nodeName,
264 event.hostname || event.host,
265 event.node || {}
266 );
267
268 return res.json({
269 success: true,
270 event: event.event,
271 linkResult: result
272 });
273 }
274
275 case 'device.disconnect':
276 case 'node.disconnect': {
277 // Update connection status
278 await db.query(
279 'UPDATE agents SET meshcentral_connected = false WHERE meshcentral_nodeid = $1',
280 [event.nodeId]
281 );
282
283 console.log(`๐Ÿ“ด [MeshWebhook] Device disconnected: ${event.nodeName}`);
284
285 return res.json({
286 success: true,
287 event: event.event,
288 updated: true
289 });
290 }
291
292 case 'device.change':
293 case 'node.change': {
294 // Device information changed - update last seen
295 await db.query(
296 'UPDATE agents SET meshcentral_last_seen = NOW() WHERE meshcentral_nodeid = $1',
297 [event.nodeId]
298 );
299
300 console.log(`๐Ÿ”„ [MeshWebhook] Device changed: ${event.nodeName}`);
301
302 return res.json({
303 success: true,
304 event: event.event,
305 updated: true
306 });
307 }
308
309 default: {
310 console.log(`โ„น๏ธ [MeshWebhook] Unhandled event type: ${event.event}`);
311 return res.json({
312 success: true,
313 event: event.event,
314 handled: false
315 });
316 }
317 }
318
319 } catch (error) {
320 console.error('โŒ [MeshWebhook] Error processing webhook:', error);
321 res.status(500).json({ error: 'Failed to process webhook', message: error.message });
322 }
323});
324
325/**
326 * @api {post} /webhooks/meshcentral-test Test webhook (no authentication)
327 * @apiName TestMeshCentralWebhook
328 * @apiGroup Webhooks
329 * @apiDescription Test endpoint for webhook development and debugging. Accepts any JSON payload and logs it to console. No authentication required.
330 * @apiParam {object} * Any JSON payload for testing.
331 * @apiSuccess {boolean} success Always true.
332 * @apiSuccess {string} message Status message.
333 * @apiSuccess {object} receivedData Echo of received payload.
334 * @apiExample {curl} Example usage:
335 * curl -X POST -d '{"test":"data"}' https://api.example.com/webhooks/meshcentral-test
336 */
337router.post('/meshcentral-test', async (req, res) => {
338 console.log('\n๐Ÿงช [MeshWebhook Test] Received test webhook:');
339 console.log(JSON.stringify(req.body, null, 2));
340
341 res.json({
342 success: true,
343 message: 'Test webhook received',
344 receivedData: req.body
345 });
346});
347
348/**
349 * @api {get} /webhooks/meshcentral/config Get webhook configuration
350 * @apiName GetWebhookConfig
351 * @apiGroup Webhooks
352 * @apiDescription Returns webhook configuration details including URLs, secret status, supported events, and sample MeshCentral config.json snippet for easy integration.
353 * @apiSuccess {string} webhookUrl Production webhook URL.
354 * @apiSuccess {string} testUrl Test webhook URL (no auth).
355 * @apiSuccess {boolean} secretConfigured Whether webhook secret is configured.
356 * @apiSuccess {string[]} events Supported event types.
357 * @apiSuccess {object} meshcentralConfig Sample MeshCentral configuration.
358 * @apiExample {curl} Example usage:
359 * curl https://api.example.com/webhooks/meshcentral/config
360 * @apiExample {json} Response example:
361 * {
362 * "webhookUrl": "https://api.example.com/webhooks/meshcentral",
363 * "testUrl": "https://api.example.com/webhooks/meshcentral-test",
364 * "secretConfigured": true,
365 * "events": ["device.connect", "device.disconnect", "device.change"],
366 * "meshcentralConfig": {
367 * "settings": {
368 * "webhooks": {
369 * "deviceEvents": "https://api.example.com/webhooks/meshcentral",
370 * "authToken": "YOUR_SECRET_HERE"
371 * }
372 * }
373 * }
374 * }
375 */
376router.get('/meshcentral/config', async (req, res) => {
377 const webhookUrl = `${process.env.BACKEND_URL || 'https://everydaytech.au'}/api/webhooks/meshcentral`;
378 const webhookSecret = process.env.MESHCENTRAL_WEBHOOK_SECRET || '(not configured)';
379
380 res.json({
381 webhookUrl: webhookUrl,
382 testUrl: `${process.env.BACKEND_URL || 'https://everydaytech.au'}/api/webhooks/meshcentral-test`,
383 secretConfigured: !!process.env.MESHCENTRAL_WEBHOOK_SECRET,
384 events: [
385 'device.connect',
386 'device.disconnect',
387 'device.change',
388 'node.connect',
389 'node.disconnect',
390 'node.change'
391 ],
392 meshcentralConfig: {
393 note: 'Add this to your MeshCentral config.json',
394 config: {
395 settings: {
396 webhooks: {
397 deviceEvents: webhookUrl,
398 authToken: webhookSecret !== '(not configured)' ? webhookSecret : 'YOUR_SECRET_HERE'
399 }
400 }
401 }
402 }
403 });
404});
405
406module.exports = router;