import { WebSocket } from 'ws'; const CONTROL_PATH = '/control.ashx'; const MAX_RESPONSE_SIZE = 1024 * 1024; const STARTUP_DELAY_MS = 750; const REQUEST_TIMEOUT_MS = 30_000; export type PluginConfig = { url: string; username: string; passwordEnv: string; groupName: string; deviceName: string; }; type MeshResponse = { action?: string; responseid?: string; meshes?: Array<{ id?: string; _id?: string; name: string; description?: string; desc?: string }>; nodes?: Record; }>>; }; function authHeader(username: string, password: string): string { return `${Buffer.from(username).toString('base64')},${Buffer.from(password).toString('base64')}`; } function endpoint(url: string): string { const wsUrl = url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); return wsUrl.endsWith(CONTROL_PATH) ? wsUrl : `${wsUrl.replace(/\/$/, '')}${CONTROL_PATH}`; } export async function inventory(config: PluginConfig): Promise<{ groups: unknown[]; devices: unknown[] }> { const password = process.env[config.passwordEnv]; if (!password) throw new Error('MeshCentral credential environment variable is not configured'); const ws = new WebSocket(endpoint(config.url), { rejectUnauthorized: true, perMessageDeflate: false, headers: { 'x-meshauth': authHeader(config.username, password) }, }); try { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { ws.terminate(); reject(new Error('MeshCentral connection timed out')); }, REQUEST_TIMEOUT_MS); ws.once('open', () => { clearTimeout(timeout); setTimeout(resolve, STARTUP_DELAY_MS); }); ws.once('error', (error: Error) => { clearTimeout(timeout); reject(error); }); }); const request = async (action: string, extra: Record = {}): Promise => { const responseid = `openclaw-${Date.now()}-${Math.random().toString(16).slice(2)}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { ws.removeListener('message', onMessage); reject(new Error('MeshCentral request timed out')); }, REQUEST_TIMEOUT_MS); const onMessage = (raw: Buffer | string): void => { const text = Buffer.isBuffer(raw) ? raw.toString() : raw; if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE) return; let parsed: MeshResponse; try { parsed = JSON.parse(text) as MeshResponse; } catch { return; } if (parsed.responseid !== responseid && !(action === 'meshes' && parsed.action === 'meshes')) return; clearTimeout(timeout); ws.removeListener('message', onMessage); resolve(parsed); }; ws.on('message', onMessage); ws.send(JSON.stringify({ action, responseid, ...extra })); }); }; const meshesResponse = await request('meshes'); const meshes = (meshesResponse.meshes ?? []).map((mesh) => ({ id: mesh.id ?? mesh._id ?? '', name: mesh.name, description: mesh.description ?? mesh.desc, })).filter((mesh) => mesh.id && mesh.name === config.groupName); if (meshes.length !== 1) throw new Error('Approved MeshCentral group was not found uniquely'); const group = meshes[0]; const nodesResponse = await request('nodes', { meshid: group.id }); const devices = Object.entries(nodesResponse.nodes ?? {}).flatMap(([groupId, nodes]) => nodes.map((node) => ({ id: node.id ?? node._id ?? '', name: node.name, groupId: node.meshid ?? groupId, online: node.online ?? node.conn === 1, os: node.device?.os ?? node.osdesc, ipAddresses: node.device?.ip ?? node.ip, }))).filter((node) => node.id && node.groupId === group.id && node.name === config.deviceName); return { groups: meshes, devices }; } finally { await new Promise((resolve) => { if (ws.readyState === WebSocket.CLOSED) return resolve(); const timeout = setTimeout(() => { ws.terminate(); resolve(); }, 2_000); ws.once('close', () => { clearTimeout(timeout); resolve(); }); ws.close(1000, 'bridge-close'); }); } }