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
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.
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
19const express = require('express');
20const router = express.Router();
21const db = require('../db');
22const MeshCentralAPI = require('../lib/meshcentral-api');
25 * Verify webhook is from MeshCentral
30function verifyWebhookAuth(req, res, next) {
31 const webhookSecret = process.env.MESHCENTRAL_WEBHOOK_SECRET;
34 // If no secret configured, allow (for development)
35 console.warn('โ ๏ธ [MeshWebhook] No MESHCENTRAL_WEBHOOK_SECRET configured');
39 const providedSecret = req.headers['x-meshcentral-secret'] || req.query.secret;
41 if (providedSecret !== webhookSecret) {
42 console.error('โ [MeshWebhook] Invalid webhook secret');
43 return res.status(401).json({ error: 'Unauthorized' });
50 * Get MeshCentral API instance
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'
64 * Try to auto-link a device to an agent
70async function autoLinkDevice(nodeId, nodeName, nodeHostname, nodeInfo = {}) {
72 console.log(`\n๐ [MeshWebhook] Auto-linking device: ${nodeHostname || nodeName} (${nodeId})`);
74 // Check if already linked
75 const existingLink = await db.query(
76 'SELECT agent_id, hostname FROM agents WHERE meshcentral_nodeid = $1',
80 if (existingLink.rows.length > 0) {
81 console.log(`โ
[MeshWebhook] Device already linked to agent ${existingLink.rows[0].agent_id}`);
82 // Update connection status
84 'UPDATE agents SET meshcentral_connected = true, meshcentral_last_seen = NOW() WHERE meshcentral_nodeid = $1',
87 return { alreadyLinked: true, agentId: existingLink.rows[0].agent_id };
90 const hostname = nodeHostname || nodeName;
92 console.log(`โ ๏ธ [MeshWebhook] Device has no hostname, cannot auto-link`);
93 return { success: false, reason: 'no_hostname' };
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',
102 if (hostnameMatch.rows.length > 0) {
103 const agent = hostnameMatch.rows[0];
105 // Link agent to device
108 SET meshcentral_nodeid = $1,
109 meshcentral_connected = true,
110 meshcentral_last_seen = NOW()
111 WHERE agent_id = $2`,
112 [nodeId, agent.agent_id]
115 console.log(`โ
[MeshWebhook] Linked agent ${agent.agent_id} (${agent.hostname}) to device ${nodeId}`);
119 agentId: agent.agent_id,
120 agentUuid: agent.agent_uuid,
121 hostname: agent.hostname,
126 // Try MAC address matching if available
127 if (nodeInfo.mac || (nodeInfo.netif && Object.keys(nodeInfo.netif).length > 0)) {
128 const macAddresses = [];
131 macAddresses.push(nodeInfo.mac);
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);
142 for (const mac of macAddresses) {
143 const macMatch = await db.query(`
144 SELECT agent_id, agent_uuid, hostname
146 WHERE meshcentral_nodeid IS NULL
147 AND hardware_data IS NOT NULL
148 AND hardware_data::text ILIKE $1
152 if (macMatch.rows.length > 0) {
153 const agent = macMatch.rows[0];
157 SET meshcentral_nodeid = $1,
158 meshcentral_connected = true,
159 meshcentral_last_seen = NOW()
160 WHERE agent_id = $2`,
161 [nodeId, agent.agent_id]
164 console.log(`โ
[MeshWebhook] Linked agent ${agent.agent_id} via MAC ${mac}`);
168 agentId: agent.agent_id,
169 agentUuid: agent.agent_uuid,
170 hostname: agent.hostname,
171 method: 'mac_address',
178 // No existing agent found - create a new one
179 console.log(`๐ [MeshWebhook] No matching agent found - creating new agent for: ${hostname}`);
181 const crypto = require('crypto');
182 const newAgentUuid = crypto.randomUUID();
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';
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;
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]
208 const newAgent = insertResult.rows[0];
209 console.log(`โ
[MeshWebhook] Created new agent ${newAgent.agent_id} (${newAgent.hostname}) linked to device ${nodeId}`);
214 agentId: newAgent.agent_id,
215 agentUuid: newAgent.agent_uuid,
216 hostname: newAgent.hostname,
217 method: 'created_new'
221 console.error('โ [MeshWebhook] Auto-link error:', error.message);
227 * @api {post} /webhooks/meshcentral Receive MeshCentral event webhook
228 * @apiName ReceiveMeshCentralWebhook
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
249router.post('/meshcentral', verifyWebhookAuth, async (req, res) => {
251 const event = req.body;
253 console.log(`\n๐ฌ [MeshWebhook] Received event: ${event.event}`);
254 console.log(` Node: ${event.nodeName || event.nodeId}`);
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(
264 event.hostname || event.host,
275 case 'device.disconnect':
276 case 'node.disconnect': {
277 // Update connection status
279 'UPDATE agents SET meshcentral_connected = false WHERE meshcentral_nodeid = $1',
283 console.log(`๐ด [MeshWebhook] Device disconnected: ${event.nodeName}`);
292 case 'device.change':
293 case 'node.change': {
294 // Device information changed - update last seen
296 'UPDATE agents SET meshcentral_last_seen = NOW() WHERE meshcentral_nodeid = $1',
300 console.log(`๐ [MeshWebhook] Device changed: ${event.nodeName}`);
310 console.log(`โน๏ธ [MeshWebhook] Unhandled event type: ${event.event}`);
320 console.error('โ [MeshWebhook] Error processing webhook:', error);
321 res.status(500).json({ error: 'Failed to process webhook', message: error.message });
326 * @api {post} /webhooks/meshcentral-test Test webhook (no authentication)
327 * @apiName TestMeshCentralWebhook
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
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));
343 message: 'Test webhook received',
344 receivedData: req.body
349 * @api {get} /webhooks/meshcentral/config Get webhook configuration
350 * @apiName GetWebhookConfig
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:
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": {
369 * "deviceEvents": "https://api.example.com/webhooks/meshcentral",
370 * "authToken": "YOUR_SECRET_HERE"
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)';
381 webhookUrl: webhookUrl,
382 testUrl: `${process.env.BACKEND_URL || 'https://everydaytech.au'}/api/webhooks/meshcentral-test`,
383 secretConfigured: !!process.env.MESHCENTRAL_WEBHOOK_SECRET,
393 note: 'Add this to your MeshCentral config.json',
397 deviceEvents: webhookUrl,
398 authToken: webhookSecret !== '(not configured)' ? webhookSecret : 'YOUR_SECRET_HERE'
406module.exports = router;