Add narrow OpenClaw MeshCentral read-only plugin

This commit is contained in:
Clawdia
2026-09-03 19:00:52 -04:00
parent ad03d23150
commit eb833dc990
11 changed files with 5480 additions and 0 deletions
@@ -0,0 +1,90 @@
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<string, Array<{
id?: string; _id?: string; name: string; meshid?: string; online?: boolean;
conn?: number; osdesc?: string; ip?: string[]; device?: Record<string, unknown>;
}>>;
};
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<void>((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<string, unknown> = {}): Promise<MeshResponse> => {
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<void>((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');
});
}
}