Add narrow OpenClaw MeshCentral read-only plugin #2

Merged
ledadmin merged 1 commits from feature/openclaw-readonly-plugin into main 2026-09-03 19:02:05 -04:00
11 changed files with 5480 additions and 0 deletions
Showing only changes of commit eb833dc990 - Show all commits
+6
View File
@@ -4,3 +4,9 @@ implementation/dist
implementation/dist/
implementation/coverage
implementation/coverage/
openclaw-plugin/node_modules
openclaw-plugin/node_modules/
openclaw-plugin/dist
openclaw-plugin/dist/
openclaw-plugin/coverage
openclaw-plugin/coverage/
@@ -0,0 +1,32 @@
import { MeshCtrlAction } from '../meshctrl-client';
import { normalizeGroupedNodes, normalizeMeshes } from '../upstream-transport';
describe('upstream MeshCentral response normalization', () => {
it('normalizes native mesh identifiers and descriptions', () => {
expect(normalizeMeshes({
action: MeshCtrlAction.Meshes,
meshes: [{ _id: 'mesh-servers', name: 'Servers', desc: 'Managed servers' }],
})).toEqual([{ id: 'mesh-servers', name: 'Servers', description: 'Managed servers' }]);
});
it('flattens grouped nodes and maps native node fields', () => {
expect(normalizeGroupedNodes({
action: MeshCtrlAction.Nodes,
nodes: {
'mesh-servers': [{
_id: 'node-pve3',
name: 'PVE3',
conn: 1,
osdesc: 'Ubuntu',
ip: ['10.10.10.13'],
}],
},
})).toEqual([{
id: 'node-pve3',
name: 'PVE3',
meshid: 'mesh-servers',
online: true,
device: { os: 'Ubuntu', ip: ['10.10.10.13'] },
}]);
});
});
+48
View File
@@ -122,3 +122,51 @@ export class UpstreamMeshTransport {
await this.close();
}
}
/** Parse and normalize MeshCentral's native grouped node response. */
export function normalizeGroupedNodes(response: MeshCtrlResponse): Array<{
id: string;
name: string;
meshid: string;
online: boolean;
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
}> {
const grouped = (response.nodes ?? {}) as Record<string, Array<{
id?: string;
_id?: string;
name: string;
meshid?: string;
online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
}>>;
return Object.entries(grouped).flatMap(([groupId, nodes]) => nodes.map((node) => ({
id: node.id ?? node._id ?? '',
name: node.name,
meshid: node.meshid ?? groupId,
online: node.online ?? node.conn === 1,
device: node.device ?? { os: node.osdesc, ip: node.ip },
}))).filter((node) => node.id !== '');
}
/** Normalize MeshCentral's native mesh identifiers and description field. */
export function normalizeMeshes(response: MeshCtrlResponse): Array<{
id: string;
name: string;
description?: string;
}> {
return ((response.meshes ?? []) as Array<{
id?: string;
_id?: string;
name: string;
description?: string;
desc?: string;
}>).map((mesh) => ({
id: mesh.id ?? mesh._id ?? '',
name: mesh.name,
description: mesh.description ?? mesh.desc,
})).filter((mesh) => mesh.id !== '');
}
+28
View File
@@ -0,0 +1,28 @@
# MeshCentral Read-only OpenClaw plugin
This package exposes a deliberately narrow OpenClaw tool surface for one approved MeshCentral group and device:
- `meshcentral_health`
- `meshcentral_list_groups`
- `meshcentral_list_devices`
- `meshcentral_device_status`
The group and device are configured, not selected by tool parameters. The password is read only from the configured environment-variable name at execution time and is never stored in plugin configuration or returned in results.
## Safety boundary
This plugin is inventory/status only. It does not expose MeshCentral commands, diagnostics, console, remote control, file transfer, power operations, relay, administration, or state-changing actions. TLS certificate validation is enabled, response sizes are bounded, requests time out, and the connection is closed after each operation.
Example configuration values for a trusted runtime:
```json
{
"url": "https://mesh.ledrew.me",
"username": "sa_clawdia",
"passwordEnv": "MESHCENTRAL_READONLY_PASSWORD",
"groupName": "Servers",
"deviceName": "PVE3"
}
```
Do not put the password itself in OpenClaw configuration, source, logs, or this repository. This package has been built and statically validated locally; it has not been installed or enabled in the OpenClaw Gateway.
+50
View File
@@ -0,0 +1,50 @@
{
"id": "meshcentral-readonly",
"name": "MeshCentral Read-only Inventory",
"description": "Read-only inventory and status for one approved MeshCentral group and device.",
"activation": {
"onStartup": true
},
"configSchema": {
"type": "object",
"required": [
"url",
"username",
"passwordEnv",
"groupName",
"deviceName"
],
"properties": {
"url": {
"type": "string",
"description": "MeshCentral base URL."
},
"username": {
"type": "string",
"description": "Dedicated least-privilege MeshCentral username."
},
"passwordEnv": {
"type": "string",
"description": "Environment variable containing the password; the password itself is never configured here."
},
"groupName": {
"type": "string",
"description": "Approved MeshCentral group name."
},
"deviceName": {
"type": "string",
"description": "Approved device name within the approved group."
}
},
"additionalProperties": false
},
"version": "0.1.0",
"contracts": {
"tools": [
"meshcentral_health",
"meshcentral_list_groups",
"meshcentral_list_devices",
"meshcentral_device_status"
]
}
}
+5080
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@ledrew/openclaw-meshcentral-readonly",
"version": "0.1.0",
"description": "OpenClaw read-only MeshCentral inventory and status tools",
"type": "module",
"main": "dist/index.js",
"files": [
"dist",
"openclaw.plugin.json",
"package.json",
"README.md"
],
"scripts": {
"build": "tsc",
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
"plugin:validate": "npm run plugin:build && openclaw plugins validate --entry ./dist/index.js",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"typebox": "^1.1.39",
"ws": "^8.21.3"
},
"peerDependencies": {
"openclaw": ">=2026.5.17"
},
"openclaw": {
"extensions": [
"./dist/index.js"
],
"compat": {
"pluginApi": ">=2026.5.17",
"minGatewayVersion": ">=2026.5.17"
}
},
"devDependencies": {
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Type } from 'typebox';
import { defineToolPlugin } from 'openclaw/plugin-sdk/tool-plugin';
import { inventory, type PluginConfig } from './meshcentral-readonly.js';
const configSchema = Type.Object({
url: Type.String({ description: 'MeshCentral base URL.' }),
username: Type.String({ description: 'Dedicated least-privilege MeshCentral username.' }),
passwordEnv: Type.String({ description: 'Environment variable containing the password; the password itself is never configured here.' }),
groupName: Type.String({ description: 'Approved MeshCentral group name.' }),
deviceName: Type.String({ description: 'Approved device name within the approved group.' }),
}, { additionalProperties: false });
const emptyParameters = Type.Object({}, { additionalProperties: false });
export default defineToolPlugin({
id: 'meshcentral-readonly',
name: 'MeshCentral Read-only Inventory',
description: 'Read-only inventory and status for one approved MeshCentral group and device.',
configSchema,
tools: (tool) => [
tool({
name: 'meshcentral_health',
label: 'MeshCentral Health',
description: 'Check authenticated read-only access to the approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return { healthy: result.groups.length === 1, group: config.groupName };
},
}),
tool({
name: 'meshcentral_list_groups',
label: 'MeshCentral Groups',
description: 'List the configured approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return result.groups;
},
}),
tool({
name: 'meshcentral_list_devices',
label: 'MeshCentral Devices',
description: 'List the configured approved device in the approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return result.devices;
},
}),
tool({
name: 'meshcentral_device_status',
label: 'MeshCentral Device Status',
description: 'Get status and safe metadata for the configured approved device.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
const device = result.devices[0];
if (!device) throw new Error('Approved MeshCentral device was not found');
return device;
},
}),
],
});
@@ -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');
});
}
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const entry = await readFile(new URL('../src/index.ts', import.meta.url), 'utf8');
const transport = await readFile(new URL('../src/meshcentral-readonly.ts', import.meta.url), 'utf8');
test('declares exactly the four read-only tools', () => {
for (const name of [
'meshcentral_health',
'meshcentral_list_groups',
'meshcentral_list_devices',
'meshcentral_device_status',
]) assert.match(entry, new RegExp(`name: '${name}'`));
assert.doesNotMatch(entry, /runcommand|power|console|filetransfer|remotecontrol|diagnostic/i);
});
test('uses fixed configured targets and an environment variable for the password', () => {
assert.match(entry, /passwordEnv/);
assert.match(transport, /process\.env\[config\.passwordEnv\]/);
assert.match(transport, /groupName/);
assert.match(transport, /deviceName/);
assert.doesNotMatch(transport, /rejectUnauthorized:\s*false/);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}