Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb833dc990 |
@@ -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'] },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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 !== '');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+5080
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user