91 lines
4.1 KiB
TypeScript
91 lines
4.1 KiB
TypeScript
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');
|
|
});
|
|
}
|
|
}
|