Add narrow OpenClaw MeshCentral read-only plugin
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user